插件系统:动态扩展Agent能力
给Claude Code装插件时,你是插件的消费者;为自己的AI应用设计插件系统时,你是平台的提供者。MCP解决了"接入外部工具",这节解决"自有工具的动态加载和第三方扩展"——让架构真正可扩展。
先看全景:工具的三个来源(插件、内置工具、MCP适配)最终都汇入同一个 Agent.tools,前端看到的始终是统一的工具调用协议——
从硬编码到插件化
现状:静态装配
ai-chat-ui当前的工具扩展方式是"静态装配"——工具是dev-server里的源码文件,agent是注册表里的一行:
packages/dev-server/src/
├── tools/
│ ├── get-time.ts # 每个工具一个文件(createTool)
│ ├── get-weather.ts
│ ├── search-docs.ts
│ └── list-components.ts
├── agents/
│ ├── chat-agent.ts # agent定义文件
│ ├── docs-agent.ts
│ └── index.ts # buildAgentDefinitions注册表
└── mcp/ # 外部MCP工具装配
text
新增一个工具=写一个文件+在agent定义里挂上;新增一个agent=定义文件+注册表加一行。在教学项目的规模下这足够好:零运行时开销、类型安全、每次改动都在review范围内。
静态装配的天花板
但它有三个天花板,规模一大就会顶到:
- 添加工具要改核心代码,重新构建发布
- 第三方开发者无法贡献工具
- 不同部署需要不同工具组合,无法用配置切换
插件系统解决的就是这三个问题——工具变成"可插拔"的模块:
# plugins.config.yaml(目标形态)
plugins:
- name: '@ai-chat/plugin-weather'
enabled: true
- name: '@ai-chat/plugin-search'
enabled: false # 按需启用
yaml
启动时动态加载,无需改代码。
插件接口设计
核心接口
// 插件接口定义
export interface Plugin {
name: string // 插件唯一标识
version: string // 语义化版本
description: string // 插件描述
tools: ToolExecutor[] // 插件提供的工具
// 可选:插件初始化
setup?(config: PluginConfig): Promise<void>
// 可选:插件清理
teardown?(): Promise<void>
}
export interface PluginConfig {
enabledTools?: string[] // 只启用指定工具
disabledTools?: string[] // 排除指定工具
[key: string]: unknown // 插件自定义配置
}
typescript
插件实现示例
天气插件:
// 天气插件
import { Plugin } from '../plugin/types'
import { weatherTool, forecastTool } from '../weather'
export const weatherPlugin: Plugin = {
name: '@ai-chat/plugin-weather',
version: '1.0.0',
description: '天气查询插件:当前天气 + 未来预报',
tools: [weatherTool, forecastTool],
async setup(config) {
// 验证API Key
if (!config.apiKey) {
console.warn('Weather plugin: no API key, using free tier')
}
},
}
typescript
数据库插件(延迟初始化):
// 数据库插件(延迟初始化)
export function createDatabasePlugin(dbUrl: string): Plugin {
return {
name: '@ai-chat/plugin-database',
version: '1.0.0',
description: '数据库查询插件',
tools: [], // 初始为空,setup时填充
async setup(config: PluginConfig) {
const { PrismaClient } = await import('@prisma/client')
const prisma = new PrismaClient({
datasources: { db: { url: dbUrl } }
})
// 动态添加工具
this.tools = [
createQueryTool(prisma, config),
createStatsTool(prisma, config),
]
},
async teardown() {
await prisma.$disconnect()
},
}
}
typescript
插件加载器
核心实现
// 插件加载器
export class PluginLoader {
private plugins = new Map<string, Plugin>()
async load(plugin: Plugin, config?: PluginConfig): Promise<void> {
if (this.plugins.has(plugin.name)) {
throw new Error(`Plugin ${plugin.name} already loaded`)
}
// 初始化插件
if (plugin.setup) {
await plugin.setup(config || {})
}
this.plugins.set(plugin.name, plugin)
console.log(`Plugin loaded: ${plugin.name} (${plugin.tools.length} tools)`)
}
async unload(name: string): Promise<void> {
const plugin = this.plugins.get(name)
if (!plugin) return
if (plugin.teardown) {
await plugin.teardown()
}
this.plugins.delete(name)
console.log(`Plugin unloaded: ${name}`)
}
getAllTools(): ToolExecutor[] {
return Array.from(this.plugins.values())
.flatMap(p => p.tools)
}
getPlugin(name: string): Plugin | undefined {
return this.plugins.get(name)
}
listPlugins(): Array<{
name: string
version: string
toolCount: number
}> {
return Array.from(this.plugins.values()).map(p => ({
name: p.name,
version: p.version,
toolCount: p.tools.length,
}))
}
}
typescript
配置文件驱动
# plugins.config.yaml
plugins:
- name: '@ai-chat/plugin-weather'
enabled: true
config:
apiKey: ${WEATHER_API_KEY}
- name: '@ai-chat/plugin-search'
enabled: true
config:
engine: 'searxng'
baseUrl: 'http://localhost:8888'
- name: '@ai-chat/plugin-database'
enabled: true
config:
url: ${DATABASE_URL}
readOnly: true
- name: '@ai-chat/plugin-slack'
enabled: false # 按需启用
yaml
启动时自动加载
// 启动时按配置自动加载
import { readFileSync } from 'node:fs'
import { parse } from 'yaml'
import { PluginLoader } from '@toimc/tools'
export async function loadPlugins(
configPath: string
): Promise<PluginLoader> {
const config = parse(
readFileSync(configPath, 'utf-8')
)
const loader = new PluginLoader()
for (const pluginConfig of config.plugins) {
if (!pluginConfig.enabled) continue
// 动态import插件
const plugin = await importPlugin(pluginConfig.name)
await loader.load(plugin, pluginConfig.config)
}
return loader
}
// 插件导入助手
async function importPlugin(name: string): Promise<Plugin> {
// 内置插件
if (name.startsWith('@ai-chat/plugin-')) {
const pkg = name.replace('@ai-chat/plugin-', '')
return (await import(`../plugins/${pkg}-plugin`)).default
}
// 第三方插件
return (await import(name)).default
}
typescript
与Hono网关集成
在网关中启用插件
// 网关集成:GatewayOptions扩展plugins字段
export interface GatewayOptions {
models: ModelConfig[] | ModelRegistry
plugins?: string // 插件配置文件路径
// ...其他字段
}
export async function createChatGateway(options: GatewayOptions): Promise<Hono> {
const app = new Hono()
// 加载插件
let pluginLoader: PluginLoader | undefined
if (options.plugins) {
pluginLoader = await loadPlugins(options.plugins)
}
// 创建工具注册
const toolRegistry = options.tools ?? (
pluginLoader
? createToolsFromPlugins(pluginLoader)
: createDefaultTools()
)
app.route('/chat', createChatRoutes({
registry,
toolRegistry,
defaultModel: options.defaultModel,
}))
// 添加插件管理路由
if (pluginLoader) {
app.route('/plugins', createPluginRoutes(pluginLoader))
}
return app
}
function createToolsFromPlugins(
loader: PluginLoader
): ToolRegistry {
const registry = new ToolRegistry()
for (const tool of loader.getAllTools()) {
registry.register(tool)
}
return registry
}
typescript
插件管理API
// 插件管理API
import { Hono } from 'hono'
export function createPluginRoutes(loader: PluginLoader): Hono {
const app = new Hono()
// 列出所有插件
app.get('/api/plugins', (c) => {
return c.json(loader.listPlugins())
})
// 获取插件详情
app.get('/api/plugins/:name', (c) => {
const name = c.req.param('name')
const plugin = loader.getPlugin(name)
if (!plugin) return c.json({ error: 'Not found' }, 404)
return c.json({
name: plugin.name,
version: plugin.version,
description: plugin.description,
tools: plugin.tools.map(t => t.definition),
})
})
// 启用/禁用插件
app.post('/api/plugins/:name/toggle', async (c) => {
const name = c.req.param('name')
const { enabled } = await c.req.json()
if (enabled) {
const plugin = await importPlugin(name)
await loader.load(plugin)
} else {
await loader.unload(name)
}
return c.json({ success: true })
})
return app
}
typescript
插件生态建设
插件发布规范
// 插件package.json结构
{
"name": "@ai-chat/plugin-weather",
"version": "1.0.0",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"peerDependencies": {
"@toimc/tools": "^1.0.0"
},
"keywords": [
"ai-chat",
"plugin",
"weather"
]
}
typescript
插件开发模板
// 插件开发模板
import { Plugin, ToolExecutor } from '@toimc/tools'
export default {
name: '@your-org/plugin-name',
version: '1.0.0',
description: '插件描述',
tools: [
// 你的工具实现
],
async setup(config) {
// 初始化逻辑:建立连接、验证配置等
},
async teardown() {
// 清理逻辑:关闭连接、释放资源等
},
} satisfies Plugin
typescript
插件市场(未来扩展)
// 插件市场(远期扩展)
export class PluginMarket {
async search(keyword: string): Promise<PluginMeta[]> {
// 从npm搜索@ai-chat/plugin-*包
const response = await fetch(
`https://registry.npmjs.org/-/v1/search?text=${encodeURIComponent(keyword)}`
)
const data = await response.json()
return data.objects
.filter((obj: any) => obj.package.name.startsWith('@ai-chat/plugin-'))
.map((obj: any) => ({
name: obj.package.name,
version: obj.package.version,
description: obj.package.description,
}))
}
async install(name: string): Promise<void> {
// 执行npm install
const { exec } = await import('node:child_process')
await new Promise((resolve, reject) => {
exec(`npm install ${name}`, (error: any) => {
if (error) reject(error)
else resolve(undefined)
})
})
}
}
typescript
前端插件管理界面
插件列表组件
<!-- 插件管理界面(宿主可选实现) -->
<script setup lang="ts">
import { ref, onMounted } from 'vue'
interface Plugin {
name: string
version: string
toolCount: number
enabled: boolean
}
const plugins = ref<Plugin[]>([])
async function loadPlugins() {
const response = await fetch('/api/plugins')
plugins.value = await response.json()
}
async function togglePlugin(name: string, enabled: boolean) {
await fetch(`/api/plugins/${name}/toggle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled }),
})
await loadPlugins()
}
onMounted(() => {
loadPlugins()
})
</script>
<template>
<div class="plugin-manager">
<h2>已安装插件</h2>
<div v-for="plugin in plugins" :key="plugin.name" class="plugin-item">
<div class="plugin-info">
<h3>{{ plugin.name }}</h3>
<p>v{{ plugin.version }} · {{ plugin.toolCount }} 个工具</p>
</div>
<button @click="togglePlugin(plugin.name, !plugin.enabled)">
{{ plugin.enabled ? '禁用' : '启用' }}
</button>
</div>
</div>
</template>
<style scoped>
.plugin-manager {
padding: 16px;
}
.plugin-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
margin-bottom: 8px;
}
</style>
vue
安全与沙箱
插件权限控制
// 插件权限声明
export interface PluginPermissions {
network?: string[] // 允许访问的域名
fs?: string[] // 允许访问的路径
env?: string[] // 允许读取的环境变量
}
export class SecurePluginLoader extends PluginLoader {
private permissions: Map<string, PluginPermissions> = new Map()
async load(
plugin: Plugin,
config?: PluginConfig,
permissions?: PluginPermissions
): Promise<void> {
// 记录权限
if (permissions) {
this.permissions.set(plugin.name, permissions)
}
// 验证权限
await this.validatePermissions(plugin, permissions)
await super.load(plugin, config)
}
private async validatePermissions(
plugin: Plugin,
permissions?: PluginPermissions
): Promise<void> {
// 检查工具是否超出权限范围
for (const tool of plugin.tools) {
await this.checkToolPermissions(tool, permissions)
}
}
private async checkToolPermissions(
tool: ToolExecutor,
permissions?: PluginPermissions
): Promise<void> {
// 分析工具的execute方法,检查是否访问受限资源
// 这可以通过静态分析或运行时监控实现
}
}
typescript
插件资源限制
// 插件资源配额
export class QuotaManager {
private quotas = new Map<string, PluginQuota>()
setQuota(pluginName: string, quota: PluginQuota): void {
this.quotas.set(pluginName, quota)
}
async checkQuota(pluginName: string): Promise<boolean> {
const quota = this.quotas.get(pluginName)
if (!quota) return true
// 检查调用次数
if (quota.maxCallsPerDay !== undefined) {
const calls = await this.getCallCount(pluginName, 'day')
if (calls >= quota.maxCallsPerDay) {
throw new Error('Daily call limit exceeded')
}
}
// 检查资源使用
if (quota.maxMemoryMB !== undefined) {
const usage = process.memoryUsage().heapUsed / 1024 / 1024
if (usage > quota.maxMemoryMB) {
throw new Error('Memory limit exceeded')
}
}
return true
}
private async getCallCount(
pluginName: string,
period: 'day' | 'hour'
): Promise<number> {
// 从存储中获取调用计数
return 0 // 占位实现
}
}
interface PluginQuota {
maxCallsPerDay?: number
maxCallsPerHour?: number
maxMemoryMB?: number
maxExecutionTime?: number // 毫秒
}
typescript
用AI生成插件
> 请帮我创建一个 @ai-chat/plugin-translate 翻译插件:
> 1. 包含 translate 工具:指定 from/to 语言和文本
> 2. 调用 LibreTranslate 自建实例(localhost:5000)
> 3. 遵循 Plugin 接口,包含 setup 和 teardown
> 4. 支持 plugins.config.yaml 中的自定义配置
text
AI会生成完整的插件代码:
// 翻译插件(AI生成示例)
import { Plugin, ToolExecutor, ToolResult } from '../types'
export const translatePlugin: Plugin = {
name: '@ai-chat/plugin-translate',
version: '1.0.0',
description: '多语言翻译插件',
tools: [translateTool],
async setup(config) {
this.baseUrl = config.baseUrl || 'http://localhost:5000'
console.log(`Translate plugin initialized: ${this.baseUrl}`)
},
async teardown() {
// 清理连接
},
}
const translateTool: ToolExecutor = {
definition: {
name: 'translate',
description: '翻译文本到指定语言',
parameters: {
text: { type: 'string', description: '待翻译文本', required: true },
from: { type: 'string', description: '源语言,如"en"、"zh"' },
to: { type: 'string', description: '目标语言', required: true },
},
},
async execute(params): Promise<ToolResult> {
const { text, from, to } = params as {
text: string
from?: string
to: string
}
try {
const response = await fetch(
`http://localhost:5000/translate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ q: text, source: from, target: to }),
}
)
const data = await response.json()
return {
success: true,
data: { translatedText: data.translatedText },
}
} catch (error) {
return {
success: false,
error: `Translation failed: ${String(error)}`,
}
}
},
}
typescript
演进路线:不要一步跳到动态npm插件
上面的设计画出了终态,但落地要按风险递增分四步走:
- 静态allowlist:插件随应用源码发布,配置只能"开关"allowlist里已有的插件ID,不能引入新包。
import()只负责代码分包和延迟加载,不构成安全边界——第三方包能读环境变量、访问文件系统、发起任意网络请求,把配置里的任意字符串直接传给import()等于把进程交出去。 - 签名manifest:插件包声明版本、所需scope、网络域名和工具schema,安装前校验签名与宿主版本兼容性。
- 独立进程/Worker隔离:高风险插件通过RPC调用,主进程只授予最小权限,叠加超时、响应大小、并发数限制。
- 运营治理:审计每次安装与工具调用,支持灰度、回滚、撤销凭证。插件市场只是这层的产品化外壳,不是loader的替代品。
ai-chat-ui停在第一步就是合理的——工具面小、全在review范围内,静态装配+环境变量门控已经覆盖需求。插件系统的价值不在"现在就用上",而在架构预留了这条路:当第三方真的想给你贡献工具时,你不需要推倒重来。
小结
- 静态装配的现状:工具=源码文件,agent=注册表一行;教学项目规模下这是对的
- 插件化核心价值:工具从硬编码到配置驱动,第三方可扩展
- Plugin接口:name + version + tools + 可选setup/teardown
- PluginLoader:动态加载/卸载插件,自动收集所有工具
- 安全与权限:沙箱执行、权限控制、资源限制确保安全
- 演进路线:静态allowlist → 签名manifest → 进程隔离 → 运营治理,动态import不是安全边界
↑